import path from "path";
import fs, { FSWatcher } from "fs";
import chokida from "chokidar";
import fastify, { FastifyInstance } from "fastify";
import { ServerResponse } from "http";
import chalk from "chalk";
import fp from "fastify-plugin";
import proxy from "fastify-http-proxy";
import fastifyStatic from "fastify-static";
import MagicString from "magic-string";
import { init as lexerInit, parse as lexerParse } from "es-module-lexer";

import { ServerConfig } from "./config";
import { resolveVue, resolveCss } from "./plugins";
import { isCss, resolvePath } from "./utils";

// constrant variable
const fsPromises = fs.promises;
const __ES_PATH__ = "/tserve/eventsource";

// declare for decorate
declare module "fastify" {
  interface FastifyInstance {
    nodeModules: Record<string, string>;
    rewriteImport: (
      source: string,
      filePath: string
    ) => string | Promise<string>;
    esConnections: ServerResponse[];
    watcher: FSWatcher; // for watch file change
  }
}

export function createServer(options: ServerConfig) {
  const server = fastify({
    logger: process.env.NODE_ENV === "development",
  });
  // start watcher
  const watcher = chokida.watch(options.root, {
    ignored: [".git", "node_modules"],
    cwd: process.cwd(),
  });
  server.decorate("watcher", watcher);
  // set static server
  server.register(fastifyStatic, {
    root: options.root,
  });
  if (options.proxy) {
    for (const item of options.proxy) {
      server.register(proxy, item);
    }
  }
  // local plugin
  server.register(eventSourcePlugin, options);
  server.register(resolveModule, options);
  server.register(resolveJs, options);
  server.register(resolveCss, options);
  server.register(resolveVue, options);
  server.register(serverIndex, options);

  // handle error
  server.addHook("onError", async (request, reply, error) => {
    console.error(chalk.red(error), `\nurl: ${request.url}`);
    reply.code(500);
    reply.send("<h1>Sorry, tserve encounter an unhandled error</h1>");
    reply.header("Content-Type", "text/html");
    return;
  });
  watcher.on("error", (error) => {
    console.error(`[tserve watcher]`, error);
  });
  watcher.on("close", () => {
    console.log("watcher is closed.");
  });
  return server;
}

// resolve module for module
const resolveModule = fp(
  async (instance: FastifyInstance, options: ServerConfig) => {
    // init lexer
    await lexerInit;

    // find module
    const nodeModulePath = path.resolve(options.root, "node_modules");
    // define file to module map, and for some special module to define manual
    const nodeModules: Record<string, string> = {
      vue: "node_modules/vue/dist/vue.esm.browser.js",
      "vue-router": "node_modules/vue-router/dist/vue-router.esm.browser.js",
    }; // module_name => path

    // read node_modules
    const promises = [];
    let stack = [...(await fsPromises.readdir(nodeModulePath))];
    let currentDir = ".";
    console.log("loading node_modules...");
    while (stack.length) {
      const dir = stack.pop()!;
      const dirKey = path.join(currentDir, dir);
      if (dir === ".bin") continue;
      if (dir === "tserve") continue;
      if (dir === "@types") continue;
      if (Object.keys(nodeModules).indexOf(dirKey) > -1) continue;
      // @ means this dir is an scope
      if (dir.startsWith("@")) {
        if (dir === currentDir) {
          // child dir traversal finish.
          if (~currentDir.indexOf("/")) {
            currentDir = currentDir.substring(0, currentDir.lastIndexOf("/"));
          } else {
            currentDir = ".";
          }
          continue;
        }
        if (currentDir === ".") {
          currentDir = dir;
        } else {
          currentDir = path.join(dir, currentDir);
        }
        stack.push(currentDir);
        stack.push(
          ...(await fsPromises.readdir(path.join(nodeModulePath, dir)))
        );
      } else {
        promises.push(
          fsPromises
            .readFile(path.resolve(nodeModulePath, dirKey, "package.json"), {
              encoding: "utf-8",
            })
            .then((content) => {
              const json = JSON.parse(content);
              if (json.main)
                nodeModules[dir] = path.join("node_modules", dirKey, json.main);
              else
                nodeModules[dir] = path.join(
                  "node_modules",
                  dirKey,
                  "index.js"
                );
              console.log(`loaded module: ${dirKey}`);
            })
            .catch((e) => {
              console.error(`error: ${e}\n, on read module ${dirKey}`);
            })
        );
      }
    }
    await Promise.all(promises);
    console.log("load finish");
    // decorate nodeModules and other plugin could use nodeModules
    instance.decorate("nodeModules", nodeModules);
    // to replace import of source
    instance.decorate(
      "rewriteImport",
      async (source: string, filePath?: string): Promise<string> => {
        const imports = lexerParse(source)[0];
        const fileMagicString = new MagicString(source);
        if (imports.length) {
          for (const item of imports) {
            const _moduleName = source.substring(item.s, item.e);
            // if _moduleName is dependencies, rewrite it to path width  node_modules
            if (nodeModules[_moduleName]) {
              console.debug(
                `replace _moduleName:${_moduleName} to ${nodeModules[_moduleName]}`
              );
              fileMagicString.overwrite(
                item.s,
                item.e,
                "/" + nodeModules[_moduleName]
              );
            } else {
              let _outPath = _moduleName;
              if (options.alias) {
                const unAliasPath = await resolvePath(
                  _outPath,
                  options,
                  filePath
                );
                if (unAliasPath) {
                  _outPath = unAliasPath;
                }
              }

              // if imoprt css module
              if (isCss.test(_outPath)) {
                console.debug(
                  `replace css module: ${_outPath} to ${_outPath}?import`
                );
                _outPath = `${_outPath}?import`;
              }
              if (_outPath !== _moduleName)
                fileMagicString.overwrite(item.s, item.e, _outPath);
            }
          }
        }
        return fileMagicString.toString();
      }
    );
  }
);

const resolveJs = fp(async (instance: FastifyInstance) => {
  // cached file
  const cachedFileMap: Record<string, string> = {};

  instance.addHook("preHandler", async (request, reply) => {
    if (request.url.startsWith("/tserve")) return;
    if (request.url.startsWith("/node_modules")) return;
    const url = request.url.split("?")[0];
    if (url.endsWith(".js")) {
      if (cachedFileMap[url]) {
        reply.type("application/javascript");
        return reply.send(cachedFileMap[url]);
      }
      try {
        const fileContent = await fsPromises.readFile(url.substring(1), {
          encoding: "utf-8",
        });
        const result = await instance.rewriteImport(
          fileContent,
          url.substring(1)
        );
        cachedFileMap[url] = result;
        console.debug(`sending file ${request.url}`);
        reply.header("Content-Type", "application/javascript");
        return reply.send(result);
      } catch (e) {
        /* handle error */
        console.error(e);
        return;
      }
    }
  });
});

// to set the eventsource
const eventSourcePlugin = fp(async (instance: FastifyInstance) => {
  // register esCoons for connect
  const esConnections: ServerResponse[] = [];
  instance.decorate("esConnections", esConnections);

  // eventsource
  instance.get(__ES_PATH__, async (request, reply) => {
    console.log(`enter eventsource:${request.id}`);
    reply.raw.writeHead(200, { "Content-Type": "text/event-stream" });
    esConnections.push(reply.raw);
  });
  instance.addHook("onClose", async (server) => {
    console.log("onClose");
    console.log(server);
    console.log(instance.esConnections);
  });
  instance.addHook("onTimeout", async () => {
    console.log("onTimeout");
    console.log(instance.esConnections);
    return;
  });
});

const serverIndex = fp(
  async (instance: FastifyInstance, options: ServerConfig) => {
    let html = "",
      clientScript = "";
    try {
      const readResult = await fsPromises.readFile(
        path.join(__dirname, "./client.js"),
        { encoding: "utf-8" }
      );
      clientScript = readResult.replace("__ES_PATH__", __ES_PATH__);
    } catch (e) {
      /* handle error */
      console.error(e);
      console.error(
        "cannot read file for client, hot module reload and css load will be disabled"
      );
    }
    try {
      html = await fsPromises.readFile(
        path.resolve(options.root, "index.html"),
        { encoding: "utf-8" }
      );
      html = html.replace(
        /<head>/i,
        '<head><script src="/tserve/client.js" type="module"></script>'
      );
      // inject script
    } catch (e) {
      /* handle error */
      console.error("no index.html here, use default html template");
      console.error(e);
      html = `
<!DOCTYPE html>
<html>
  <head>
    <script type="module" src="/tserve/client.js" charset="utf-8"></script>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <title>App</title>
  </head>
  <body>
    body
  </body>
</html>
    `;
    }

    instance.get("/tserve/client.js", async (_, reply) => {
      reply.type("application/javascript");
      reply.send(clientScript);
    });
    // if no match, return index.html
    instance.get("/", async (_, reply) => {
      reply.header("Content-Type", "text/html");
      return reply.send(html);
    });
  }
);
